Skip to main content

Middleware Pattern

Middleware Pattern in JS

The Middleware Pattern (also called Chain of Responsibility) passes a request through a pipeline of handlers — each handler can process, transform, or short-circuit the request.

Think of it like this:

"Every request passes through a chain of checkpoints. Each checkpoint can do its job, pass it along, or stop it."


What is Middleware Pattern?

It is a behavioral design pattern where:

  • a request travels through a sequence of handlers
  • each handler can act on the request, modify it, or stop the chain
  • handlers are independent and can be added/removed/reordered without affecting others

You use this pattern every day — it's the core of Express, Koa, Fastify, and most HTTP frameworks.


Real-world analogy

Think of airport security:

  1. Ticket check — are you on the flight?
  2. ID check — is your identity valid?
  3. Baggage scan — anything suspicious?
  4. Body scan — final check

Each step is independent. If any step fails, you don't continue. If all pass, you board.


How Express Middleware Works

app.use((req, res, next) => {
// do something
next(); // pass to next middleware
});
  • req — the incoming request
  • res — the response object
  • next — call to pass control to the next handler

This is the Middleware Pattern.


Building Your Own Middleware Pipeline

Understanding the internals helps you build custom pipelines beyond HTTP.

The pipeline

function createPipeline(middlewares) {
return async function (context) {
let index = 0;

async function next() {
if (index >= middlewares.length) return;
const middleware = middlewares[index++];
await middleware(context, next);
}

await next();
return context;
};
}

Middlewares

async function logger(ctx, next) {
console.log(`[${new Date().toISOString()}] Processing: ${ctx.action}`);
await next();
console.log(`[done] ${ctx.action}`);
}

async function authenticate(ctx, next) {
if (!ctx.user) throw new Error('Unauthenticated');
await next();
}

async function authorize(requiredRole) {
return async (ctx, next) => {
if (ctx.user.role !== requiredRole) throw new Error('Forbidden');
await next();
};
}

async function validate(schema) {
return async (ctx, next) => {
const { error } = schema.validate(ctx.body);
if (error) throw new Error(`Validation: ${error.message}`);
await next();
};
}

Usage

const pipeline = createPipeline([
logger,
authenticate,
await authorize('admin'),
]);

const ctx = {
action: 'deleteUser',
user: { id: 1, role: 'admin' },
body: { userId: 42 },
};

await pipeline(ctx);

Practical Express Middleware Examples

1) Request Logger

// middleware/logger.js
export function requestLogger(req, res, next) {
const start = Date.now();

res.on('finish', () => {
const duration = Date.now() - start;
console.log(
JSON.stringify({
method: req.method,
path: req.path,
status: res.statusCode,
duration: `${duration}ms`,
ip: req.ip,
userId: req.user?.id,
})
);
});

next();
}
app.use(requestLogger);

2) Authentication Middleware

// middleware/auth.js
import jwt from 'jsonwebtoken';

export function authenticate(req, res, next) {
const authHeader = req.headers.authorization;

if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}

const token = authHeader.split(' ')[1];

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
// Apply to specific routes
router.get('/orders', authenticate, getOrders);
router.post('/orders', authenticate, createOrder);

3) Role-Based Authorization

// middleware/authorize.js
export function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthenticated' });
}

if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: `Requires one of: ${roles.join(', ')}`,
});
}

next();
};
}
// Combine auth + role check
router.delete('/users/:id', authenticate, authorize('admin'), deleteUser);
router.post('/reports', authenticate, authorize('admin', 'manager'), createReport);

4) Request Validation Middleware

// middleware/validate.js
import Joi from 'joi';

export function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });

if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map(d => d.message),
});
}

req.body = value; // use validated + sanitized value
next();
};
}
// schemas/order.js
import Joi from 'joi';

export const createOrderSchema = Joi.object({
userId: Joi.string().uuid().required(),
items: Joi.array().items(
Joi.object({
productId: Joi.string().required(),
qty: Joi.number().integer().min(1).required(),
})
).min(1).required(),
});
router.post('/orders',
authenticate,
validate(createOrderSchema),
createOrder
);

5) Rate Limiter Middleware

// middleware/rateLimit.js
const requestCounts = new Map(); // userId → { count, resetAt }

export function rateLimit({ maxRequests = 100, windowMs = 60_000 } = {}) {
return (req, res, next) => {
const key = req.user?.id || req.ip;
const now = Date.now();
const data = requestCounts.get(key);

if (!data || now > data.resetAt) {
requestCounts.set(key, { count: 1, resetAt: now + windowMs });
return next();
}

if (data.count >= maxRequests) {
return res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil((data.resetAt - now) / 1000),
});
}

data.count++;
next();
};
}
// Apply globally or per-route
app.use(rateLimit({ maxRequests: 200, windowMs: 60_000 }));

// Stricter limit on sensitive routes
router.post('/login', rateLimit({ maxRequests: 5, windowMs: 60_000 }), login);

6) Error Handling Middleware

Express error middleware takes 4 arguments — Express detects this signature.

// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
console.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
userId: req.user?.id,
});

// Known app errors
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}

if (err.name === 'NotFoundError') {
return res.status(404).json({ error: err.message });
}

if (err.name === 'UnauthorizedError') {
return res.status(401).json({ error: 'Unauthorized' });
}

// Unknown errors — don't leak internals
res.status(500).json({ error: 'Internal server error' });
}
// Must be registered LAST
app.use(errorHandler);

7) Async Error Wrapper

Async route handlers don't automatically forward errors to next. Wrap them:

// middleware/asyncHandler.js
export function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Without wrapper — uncaught async errors
router.get('/orders/:id', async (req, res) => {
const order = await orderService.getOrder(req.params.id); // throws → unhandled
res.json(order);
});

// With wrapper — errors reach errorHandler middleware
router.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await orderService.getOrder(req.params.id);
res.json(order);
}));

Composing Middleware in a Consistent Pattern

// controllers/orderController.js
import { authenticate } from '../middleware/auth.js';
import { authorize } from '../middleware/authorize.js';
import { validate } from '../middleware/validate.js';
import { asyncHandler } from '../middleware/asyncHandler.js';
import { createOrderSchema } from '../schemas/order.js';

const orderMiddleware = [
authenticate,
authorize('user', 'admin'),
validate(createOrderSchema),
];

router.post('/orders',
...orderMiddleware,
asyncHandler(async (req, res) => {
const order = await orderService.placeOrder(req.user.id, req.body.items);
res.status(201).json(order);
})
);

Every concern is its own middleware. The route handler contains only business logic.


Non-HTTP Use Cases

Middleware pattern isn't just for HTTP. Use it for:

Job Queue Pipeline

async function processJob(job) {
const pipeline = createPipeline([
logJob,
validateJobPayload,
deduplicateJob,
executeJob,
markJobComplete,
]);

await pipeline({ job });
}

Event Processing Pipeline

const eventPipeline = createPipeline([
enrichEventWithUserData,
filterSpamEvents,
transformEventSchema,
publishToKafka,
]);

await eventPipeline({ event: incomingEvent });

Data Import Pipeline

const importPipeline = createPipeline([
parseCsv,
validateRows,
deduplicateRows,
enrichWithExternalData,
batchInsertToDb,
sendSummaryEmail,
]);

await importPipeline({ filePath: 'orders.csv' });

Benefits

1. Separation of concerns — each middleware does one thing.

2. Composable — mix and match middlewares across routes.

3. Reusableauthenticate and rateLimit work on any route.

4. Testable — test each middleware in isolation.

test('authenticate rejects missing token', () => {
const req = { headers: {} };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
const next = jest.fn();

authenticate(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});

5. Short-circuit on failure — any handler can stop the chain (return without calling next()).


When to use Middleware Pattern

  • HTTP request lifecycle (auth, validation, logging, rate limiting)
  • Processing pipelines (ETL, event processing, job queues)
  • Plugin systems where external code hooks into your flow
  • Anywhere you have a sequence of independent processing steps

When NOT to use it

  • when steps have complex dependencies and must share state in non-obvious ways
  • when the sequence is fixed and simple — just call functions sequentially
  • when the indirection makes it hard to trace the execution flow

Interview definition (short answer)

"Middleware Pattern (Chain of Responsibility) passes a request through a pipeline of handlers. Each handler can process the request, modify it, or stop the chain. It decouples the sender from the handlers and makes behavior composable."


Formula:

Request → [Middleware1 → Middleware2 → Middleware3 → ...] → Response
↑ any can short-circuit